iT邦幫忙

2025 iThome 鐵人賽

DAY 13
0
Modern Web

30 天 Rails 新手村:從工作專案學會 Ruby on Rails系列 第 13

Day 12: 例外處理與錯誤回應設計 - 將失敗轉化為優雅的使用者體驗

  • 分享至 

  • xImage
  •  

從其他框架的錯誤處理談起

如果你來自 Express.js 的世界,你可能習慣了在每個路由末端加上錯誤處理中介軟體,用 next(error) 將錯誤往下傳遞。在 Spring Boot 中,你會用 @ExceptionHandler@ControllerAdvice 來集中處理例外。Python 的 FastAPI 則提供了優雅的 HTTPException 和自訂例外處理器。

今天我們要探討的是 Rails 如何將錯誤處理提升到另一個層次。在 Rails 的世界裡,錯誤不只是程式的異常狀態,更是與使用者溝通的機會。我們不只要「處理」錯誤,更要「設計」錯誤體驗。

為什麼說錯誤處理如此重要?想像你正在開發的 LMS 系統:學生提交作業時遇到網路問題、講師上傳超過限制的影片檔案、管理員嘗試刪除還有學生註冊的課程。每個錯誤場景都是系統與使用者的接觸點,處理得好能建立信任,處理不當則會造成挫折。

在第二週的學習中,我們已經建立了認證、授權和 API 版本控制。這些功能都會產生各種錯誤:token 過期、權限不足、版本不支援。今天我們要建立一個統一、優雅、可追蹤的錯誤處理系統,為明天的測試驅動開發打下基礎。

Rails 的例外處理哲學

錯誤的層次結構

Rails 將錯誤分為幾個清晰的層次,每個層次有不同的處理策略:

# Rails 的錯誤層次體系
module ErrorHierarchy
  # 層次一:框架層錯誤
  # Rails 內建的標準錯誤,通常對應到 HTTP 狀態碼
  # - ActiveRecord::RecordNotFound → 404
  # - ActionController::ParameterMissing → 400
  # - ActionController::RoutingError → 404
  
  # 層次二:業務邏輯錯誤
  # 應用程式特定的業務規則違反
  class BusinessLogicError < StandardError; end
  class InsufficientPermissionError < BusinessLogicError; end
  class CourseFullError < BusinessLogicError; end
  
  # 層次三:外部服務錯誤
  # 第三方 API、資料庫連線等外部依賴的錯誤
  class ExternalServiceError < StandardError; end
  class PaymentGatewayError < ExternalServiceError; end
  class VideoProcessingError < ExternalServiceError; end
end

這種分層不是任意的設計,而是基於不同錯誤需要不同的處理策略。框架層錯誤通常有標準的處理方式,業務邏輯錯誤需要友善的使用者提示,外部服務錯誤則需要重試機制和降級策略。

與其他框架的對比

讓我們比較不同框架的錯誤處理理念:

框架 設計理念 實作方式 優劣權衡
Rails 約定優於配置,統一處理 rescue_from 宣告式處理 簡潔但需要理解約定
Express 中介軟體鏈式處理 錯誤處理中介軟體 靈活但容易遺漏
Spring Boot 註解驅動,AOP 切面 @ExceptionHandler 強大但較複雜
FastAPI 型別驅動,明確定義 例外類別與處理器 清晰但較繁瑣

Rails 的優勢在於它提供了一套完整的錯誤處理約定。你不需要在每個控制器重複相同的錯誤處理邏輯,而是在 ApplicationController 中統一定義。

建立統一的錯誤處理機制

第一步:基礎錯誤處理架構

讓我們從最基礎的錯誤處理開始,逐步建立完整的系統:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # 宣告式的錯誤處理,從最具體到最通用的順序
  rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
  rescue_from ActionController::ParameterMissing, with: :handle_bad_request
  rescue_from StandardError, with: :handle_internal_error
  
  private
  
  def handle_not_found(exception)
    render_error(:not_found, '找不到指定的資源')
  end
  
  def handle_bad_request(exception)
    render_error(:bad_request, '請求參數不完整')
  end
  
  def handle_internal_error(exception)
    # 在開發環境顯示詳細錯誤,生產環境只顯示通用訊息
    if Rails.env.development?
      render_error(:internal_server_error, exception.message)
    else
      render_error(:internal_server_error, '系統錯誤,請稍後再試')
    end
  end
  
  def render_error(status, message)
    render json: {
      error: {
        status: Rack::Utils::SYMBOL_TO_STATUS_CODE[status],
        message: message,
        timestamp: Time.current.iso8601
      }
    }, status: status
  end
end

第二步:設計一致的錯誤回應格式

統一的錯誤格式不只是為了美觀,更是為了讓前端能夠一致地處理錯誤:

# app/models/concerns/error_response.rb
module ErrorResponse
  extend ActiveSupport::Concern
  
  # 錯誤回應的標準結構
  # 參考了 JSON API 規範和 RFC 7807 (Problem Details)
  class ErrorSerializer
    attr_reader :status, :errors
    
    def initialize(status, errors = [])
      @status = status
      @errors = errors.is_a?(Array) ? errors : [errors]
    end
    
    def to_json
      {
        error: {
          status: status_code,
          type: error_type,
          title: error_title,
          detail: error_detail,
          errors: formatted_errors,
          timestamp: Time.current.iso8601,
          request_id: Current.request_id # 用於追蹤錯誤
        }
      }
    end
    
    private
    
    def status_code
      Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
    end
    
    def error_type
      # 提供機器可讀的錯誤類型
      case status
      when :not_found then 'resource_not_found'
      when :unprocessable_entity then 'validation_failed'
      when :unauthorized then 'authentication_required'
      when :forbidden then 'permission_denied'
      else 'unknown_error'
      end
    end
    
    def error_title
      # 人類可讀的錯誤標題
      I18n.t("errors.titles.#{status}", default: '發生錯誤')
    end
    
    def error_detail
      # 詳細的錯誤說明
      I18n.t("errors.details.#{status}", default: errors.first.to_s)
    end
    
    def formatted_errors
      errors.map do |error|
        case error
        when ActiveModel::Errors
          format_validation_errors(error)
        when Hash
          error
        else
          { message: error.to_s }
        end
      end
    end
    
    def format_validation_errors(errors)
      errors.map do |attribute, message|
        {
          field: attribute,
          message: message,
          code: "invalid_#{attribute}"
        }
      end
    end
  end
end

第三步:處理業務邏輯錯誤

LMS 系統有許多特定的業務規則,違反這些規則時需要清楚的錯誤提示:

# app/errors/business_errors.rb
module BusinessErrors
  # 基礎業務錯誤類別
  class BaseError < StandardError
    attr_reader :code, :status, :details
    
    def initialize(message = nil, code: nil, status: :unprocessable_entity, details: {})
      @code = code || self.class.name.demodulize.underscore
      @status = status
      @details = details
      super(message || default_message)
    end
    
    private
    
    def default_message
      I18n.t("errors.business.#{@code}", default: '業務邏輯錯誤')
    end
  end
  
  # LMS 特定的業務錯誤
  class CourseFullError < BaseError
    def initialize(course, max_students)
      super(
        "課程已額滿",
        code: 'course_full',
        details: {
          course_id: course.id,
          current_students: course.enrollments.count,
          max_students: max_students
        }
      )
    end
  end
  
  class EnrollmentDeadlinePassedError < BaseError
    def initialize(course, deadline)
      super(
        "註冊期限已過",
        code: 'enrollment_deadline_passed',
        details: {
          course_id: course.id,
          deadline: deadline.iso8601
        }
      )
    end
  end
  
  class InsufficientCourseProgressError < BaseError
    def initialize(required_progress, current_progress)
      super(
        "學習進度不足",
        code: 'insufficient_progress',
        details: {
          required: required_progress,
          current: current_progress,
          missing: required_progress - current_progress
        }
      )
    end
  end
  
  class AssignmentAlreadySubmittedError < BaseError
    def initialize(assignment)
      super(
        "作業已經提交,無法重複提交",
        code: 'assignment_already_submitted',
        status: :conflict,
        details: {
          assignment_id: assignment.id,
          submitted_at: assignment.submitted_at.iso8601
        }
      )
    end
  end
end

# 在控制器中使用業務錯誤
class EnrollmentsController < ApplicationController
  rescue_from BusinessErrors::BaseError do |error|
    render json: ErrorSerializer.new(
      error.status,
      {
        code: error.code,
        message: error.message,
        details: error.details
      }
    ).to_json, status: error.status
  end
  
  def create
    course = Course.find(params[:course_id])
    
    # 檢查業務規則
    if course.full?
      raise BusinessErrors::CourseFullError.new(course, course.max_students)
    end
    
    if course.enrollment_deadline.past?
      raise BusinessErrors::EnrollmentDeadlinePassedError.new(course, course.enrollment_deadline)
    end
    
    # 正常的註冊流程
    enrollment = current_user.enrollments.create!(course: course)
    render json: enrollment, status: :created
  end
end

錯誤追蹤與監控

整合錯誤追蹤服務

生產環境的錯誤追蹤是關鍵。讓我們整合 Sentry(也可以選擇 Rollbar 或 Honeybadger):

# config/initializers/sentry.rb
Sentry.init do |config|
  config.dsn = Rails.credentials.sentry[:dsn]
  config.breadcrumbs_logger = [:active_support_logger, :http_logger]
  
  # 環境配置
  config.enabled_environments = %w[production staging]
  config.environment = Rails.env
  
  # 效能監控
  config.traces_sample_rate = 0.1 # 採樣 10% 的請求
  config.profiles_sample_rate = 0.1
  
  # 過濾敏感資訊
  config.before_send = lambda do |event, hint|
    # 移除敏感參數
    if event.request && event.request.data
      event.request.data = filter_sensitive_data(event.request.data)
    end
    event
  end
  
  # 自訂標籤,方便分類和搜尋
  config.set_tags = lambda do |scope|
    scope.set_tag(:api_version, Current.api_version)
    scope.set_tag(:user_role, Current.user&.role)
    scope.set_tag(:feature, Current.feature_name)
  end
end

# 建立錯誤上下文追蹤
class ApplicationController < ActionController::API
  around_action :track_error_context
  
  private
  
  def track_error_context
    # 設定 Sentry 上下文
    Sentry.with_scope do |scope|
      scope.set_user(
        id: current_user&.id,
        email: current_user&.email,
        role: current_user&.role
      )
      
      scope.set_context('request', {
        url: request.url,
        method: request.method,
        ip: request.remote_ip,
        user_agent: request.user_agent
      })
      
      yield
    end
  rescue => exception
    # 記錄額外的診斷資訊
    Sentry.capture_exception(exception) do |scope|
      scope.set_context('diagnostics', {
        controller: controller_name,
        action: action_name,
        params: filtered_params,
        session_id: session.id
      })
    end
    
    raise # 重新拋出例外,讓 rescue_from 處理
  end
  
  def filtered_params
    # 過濾敏感參數
    params.except(:password, :token, :secret).to_unsafe_h
  end
end

建立錯誤分析儀表板

除了即時追蹤,我們還需要分析錯誤趨勢:

# app/models/error_metric.rb
class ErrorMetric < ApplicationRecord
  # 使用 PostgreSQL 的 JSONB 儲存錯誤詳情
  # 這樣可以靈活查詢又保持效能
  
  scope :recent, -> { where('created_at > ?', 24.hours.ago) }
  scope :by_status, ->(status) { where(status: status) }
  scope :by_controller, ->(controller) { where("details->>'controller' = ?", controller) }
  
  # 記錄錯誤指標
  def self.track_error(exception, context = {})
    create!(
      error_type: exception.class.name,
      message: exception.message,
      status: context[:status] || 500,
      details: {
        controller: context[:controller],
        action: context[:action],
        user_id: context[:user_id],
        request_id: context[:request_id],
        backtrace: exception.backtrace&.first(5) # 只存前 5 行
      },
      occurred_at: Time.current
    )
  end
  
  # 分析方法
  def self.error_rate(period = 1.hour)
    total_requests = RequestMetric.where('created_at > ?', period.ago).count
    error_count = where('occurred_at > ?', period.ago).count
    
    return 0 if total_requests.zero?
    (error_count.to_f / total_requests * 100).round(2)
  end
  
  def self.top_errors(limit = 10)
    group(:error_type)
      .where('occurred_at > ?', 24.hours.ago)
      .order('count_all DESC')
      .limit(limit)
      .count
  end
  
  def self.error_trend(days = 7)
    where('occurred_at > ?', days.days.ago)
      .group_by_day(:occurred_at)
      .count
  end
end

# 建立背景任務定期分析
class ErrorAnalysisJob < ApplicationJob
  queue_as :low_priority
  
  def perform
    # 計算關鍵指標
    metrics = {
      error_rate_1h: ErrorMetric.error_rate(1.hour),
      error_rate_24h: ErrorMetric.error_rate(24.hours),
      top_errors: ErrorMetric.top_errors,
      trend: ErrorMetric.error_trend
    }
    
    # 檢查是否需要告警
    if metrics[:error_rate_1h] > 5.0 # 錯誤率超過 5%
      ErrorAlertMailer.high_error_rate(metrics).deliver_later
    end
    
    # 存入 Redis 供儀表板使用
    Rails.cache.write('error_metrics', metrics, expires_in: 5.minutes)
  end
end

進階錯誤處理模式

優雅降級與斷路器模式

當外部服務出現問題時,我們需要優雅降級而非完全失敗:

# app/services/circuit_breaker.rb
class CircuitBreaker
  attr_reader :name, :failure_threshold, :timeout, :reset_timeout
  
  def initialize(name, failure_threshold: 5, timeout: 60, reset_timeout: 120)
    @name = name
    @failure_threshold = failure_threshold
    @timeout = timeout
    @reset_timeout = reset_timeout
    @state = :closed
    @failures = 0
    @last_failure_time = nil
  end
  
  def call
    case @state
    when :open
      if can_attempt_reset?
        @state = :half_open
        attempt_call { yield }
      else
        raise CircuitOpenError, "斷路器開啟中:#{@name}"
      end
    when :half_open
      attempt_call { yield }
    when :closed
      attempt_call { yield }
    end
  end
  
  private
  
  def attempt_call
    result = yield
    on_success
    result
  rescue => exception
    on_failure(exception)
    raise
  end
  
  def on_success
    @failures = 0
    @state = :closed if @state == :half_open
  end
  
  def on_failure(exception)
    @failures += 1
    @last_failure_time = Time.current
    
    if @failures >= @failure_threshold
      @state = :open
      Rails.logger.error "斷路器開啟:#{@name},失敗次數:#{@failures}"
    end
  end
  
  def can_attempt_reset?
    @last_failure_time && Time.current - @last_failure_time > @reset_timeout
  end
  
  class CircuitOpenError < StandardError; end
end

# 在 LMS 中使用斷路器保護外部服務
class VideoProcessingService
  def initialize
    @circuit_breaker = CircuitBreaker.new(
      'video_processing',
      failure_threshold: 3,
      timeout: 30,
      reset_timeout: 60
    )
  end
  
  def process_video(video_file)
    @circuit_breaker.call do
      # 呼叫外部影片處理服務
      external_api_call(video_file)
    end
  rescue CircuitBreaker::CircuitOpenError => e
    # 降級策略:返回預設值或使用快取
    Rails.logger.warn "影片處理服務暫時不可用,使用降級策略"
    {
      status: 'pending',
      message: '影片處理服務暫時繁忙,請稍後再試',
      retry_after: 60
    }
  end
end

重試機制與指數退避

某些暫時性錯誤值得重試,但要避免雪崩效應:

# app/models/concerns/retryable.rb
module Retryable
  extend ActiveSupport::Concern
  
  class_methods do
    def with_retry(max_attempts: 3, base_delay: 1, max_delay: 16, exceptions: [StandardError])
      attempt = 0
      delay = base_delay
      
      begin
        attempt += 1
        yield
      rescue *exceptions => e
        if attempt >= max_attempts
          Rails.logger.error "重試失敗:#{e.message},嘗試次數:#{attempt}"
          raise
        end
        
        # 指數退避:1, 2, 4, 8, 16...
        delay = [delay * 2, max_delay].min
        
        # 加入隨機抖動避免同時重試
        actual_delay = delay * (0.5 + rand * 0.5)
        
        Rails.logger.info "重試中:第 #{attempt} 次嘗試,等待 #{actual_delay.round(2)} 秒"
        sleep(actual_delay)
        
        retry
      end
    end
  end
end

# 使用重試機制的實際案例
class PaymentService
  include Retryable
  
  def charge_user(user, amount)
    with_retry(
      max_attempts: 3,
      base_delay: 2,
      exceptions: [Net::ReadTimeout, Stripe::APIConnectionError]
    ) do
      Stripe::Charge.create(
        amount: amount,
        currency: 'twd',
        customer: user.stripe_customer_id,
        description: "LMS 課程費用"
      )
    end
  rescue Stripe::CardError => e
    # 信用卡錯誤不應該重試
    raise BusinessErrors::PaymentFailedError.new(e.message)
  end
end

在 LMS 系統中的實際應用

課程註冊的完整錯誤處理

讓我們看看如何在 LMS 的課程註冊功能中整合所有的錯誤處理概念:

# app/controllers/api/v1/enrollments_controller.rb
module Api
  module V1
    class EnrollmentsController < ApplicationController
      # 定義特定的錯誤處理
      rescue_from BusinessErrors::CourseFullError, with: :handle_course_full
      rescue_from BusinessErrors::EnrollmentDeadlinePassedError, with: :handle_deadline_passed
      rescue_from PaymentService::PaymentError, with: :handle_payment_error
      
      def create
        # 使用事務確保資料一致性
        enrollment = nil
        
        ActiveRecord::Base.transaction do
          course = find_course
          validate_enrollment_eligibility(course)
          
          # 建立註冊記錄
          enrollment = current_user.enrollments.build(
            course: course,
            enrolled_at: Time.current,
            status: 'pending_payment'
          )
          
          # 處理付款(可能失敗)
          process_payment(course) if course.paid?
          
          enrollment.status = 'active'
          enrollment.save!
          
          # 發送確認郵件(使用背景任務避免阻塞)
          EnrollmentMailer.confirmation(enrollment).deliver_later
        end
        
        render json: EnrollmentSerializer.new(enrollment), status: :created
      rescue ActiveRecord::RecordInvalid => e
        # 資料驗證失敗
        render_validation_errors(e.record.errors)
      end
      
      private
      
      def find_course
        Course.find(params[:course_id])
      rescue ActiveRecord::RecordNotFound
        raise BusinessErrors::CourseNotFoundError.new(params[:course_id])
      end
      
      def validate_enrollment_eligibility(course)
        # 檢查多個業務規則
        validator = EnrollmentValidator.new(course, current_user)
        
        unless validator.valid?
          raise BusinessErrors::EnrollmentNotAllowedError.new(
            validator.error_message,
            details: validator.error_details
          )
        end
      end
      
      def process_payment(course)
        PaymentService.new.charge_for_course(
          user: current_user,
          course: course,
          amount: course.price,
          idempotency_key: "enrollment_#{current_user.id}_#{course.id}"
        )
      rescue PaymentService::PaymentError => e
        # 記錄付款失敗但不中斷註冊
        ErrorMetric.track_error(e, {
          controller: 'enrollments',
          action: 'create',
          user_id: current_user.id
        })
        
        # 標記為待付款狀態
        enrollment.status = 'pending_payment'
        enrollment.payment_failure_reason = e.message
      end
      
      def handle_course_full(error)
        render json: {
          error: {
            type: 'course_full',
            message: '抱歉,此課程已額滿',
            details: {
              course_id: error.details[:course_id],
              waitlist_available: true,
              waitlist_position: error.details[:waitlist_position]
            }
          }
        }, status: :conflict
      end
      
      def handle_deadline_passed(error)
        render json: {
          error: {
            type: 'enrollment_closed',
            message: '註冊期限已過',
            details: {
              deadline: error.details[:deadline],
              next_session: error.details[:next_session_date]
            }
          }
        }, status: :unprocessable_entity
      end
      
      def handle_payment_error(error)
        # 付款錯誤需要特別處理
        Sentry.capture_exception(error, level: :warning)
        
        render json: {
          error: {
            type: 'payment_failed',
            message: '付款處理失敗',
            details: {
              reason: error.user_message, # 不洩漏技術細節
              support_ticket_id: create_support_ticket(error)
            }
          }
        }, status: :payment_required
      end
      
      def create_support_ticket(error)
        # 自動建立客服工單
        SupportTicket.create!(
          user: current_user,
          category: 'payment_issue',
          description: error.technical_message,
          priority: 'high'
        ).ticket_number
      end
    end
  end
end

錯誤處理的測試策略

完整的錯誤處理需要完整的測試覆蓋:

# spec/controllers/api/v1/enrollments_controller_spec.rb
RSpec.describe Api::V1::EnrollmentsController, type: :request do
  let(:user) { create(:user) }
  let(:course) { create(:course) }
  let(:headers) { auth_headers(user) }
  
  describe 'POST /api/v1/courses/:course_id/enrollments' do
    context '正常註冊流程' do
      it '成功建立註冊記錄' do
        post "/api/v1/courses/#{course.id}/enrollments", headers: headers
        
        expect(response).to have_http_status(:created)
        expect(json_response['enrollment']['status']).to eq('active')
      end
    end
    
    context '錯誤處理' do
      context '當課程不存在' do
        it '返回 404 錯誤' do
          post '/api/v1/courses/999999/enrollments', headers: headers
          
          expect(response).to have_http_status(:not_found)
          expect(json_response['error']['type']).to eq('resource_not_found')
        end
      end
      
      context '當課程已額滿' do
        before do
          # 填滿課程
          create_list(:enrollment, course.max_students, course: course)
        end
        
        it '返回衝突錯誤並提供候補資訊' do
          post "/api/v1/courses/#{course.id}/enrollments", headers: headers
          
          expect(response).to have_http_status(:conflict)
          expect(json_response['error']['type']).to eq('course_full')
          expect(json_response['error']['details']).to include('waitlist_available')
        end
      end
      
      context '當付款失敗' do
        before do
          allow_any_instance_of(PaymentService)
            .to receive(:charge_for_course)
            .and_raise(PaymentService::PaymentError.new('Card declined'))
        end
        
        it '返回付款錯誤但仍建立待付款註冊' do
          post "/api/v1/courses/#{course.id}/enrollments", headers: headers
          
          expect(response).to have_http_status(:payment_required)
          expect(json_response['error']['type']).to eq('payment_failed')
          
          # 確認註冊記錄已建立但狀態為待付款
          enrollment = user.enrollments.last
          expect(enrollment).to be_present
          expect(enrollment.status).to eq('pending_payment')
        end
        
        it '記錄錯誤到監控系統' do
          expect(Sentry).to receive(:capture_exception).once
          
          post "/api/v1/courses/#{course.id}/enrollments", headers: headers
        end
      end
      
      context '併發註冊處理' do
        it '正確處理競爭條件' do
          # 模擬最後一個名額的競爭
          create_list(:enrollment, course.max_students - 1, course: course)
          
          # 使用執行緒模擬併發請求
          results = []
          threads = 2.times.map do
            Thread.new do
              post "/api/v1/courses/#{course.id}/enrollments", headers: headers
              results << response.status
            end
          end
          threads.each(&:join)
          
          # 應該有一個成功,一個失敗
          expect(results).to include(201) # created
          expect(results).to include(409) # conflict
        end
      end
    end
  end
end

深度思考:錯誤處理的藝術

轉職者常見誤區

誤區一:過度捕捉例外

來自 Java 背景的開發者可能習慣捕捉所有例外:

# 錯誤示範:過度防禦
def process_data
  begin
    # 所有程式碼都包在 begin-rescue 中
    user = User.find(params[:id])
    course = Course.find(params[:course_id])
    # ... 更多邏輯
  rescue => e
    # 捕捉所有錯誤,掩蓋了真正的問題
    render json: { error: '發生錯誤' }
  end
end

# 正確做法:讓框架處理預期的錯誤
def process_data
  user = User.find(params[:id]) # 讓 Rails 處理 RecordNotFound
  course = Course.find(params[:course_id])
  
  # 只捕捉特定的業務邏輯錯誤
  validate_enrollment!(user, course)
rescue BusinessErrors::EnrollmentError => e
  # 針對性處理
  render_business_error(e)
end

誤區二:洩漏技術細節

來自 Node.js 的開發者可能習慣直接返回錯誤物件:

# 錯誤示範:洩漏內部實作
rescue ActiveRecord::StatementInvalid => e
  render json: {
    error: e.message, # "PG::UniqueViolation: ERROR: duplicate key value..."
    backtrace: e.backtrace # 整個堆疊追蹤
  }

# 正確做法:提供有用但安全的錯誤訊息
rescue ActiveRecord::StatementInvalid => e
  # 記錄詳細錯誤供內部除錯
  Rails.logger.error "Database error: #{e.message}"
  Sentry.capture_exception(e)
  
  # 返回友善的錯誤訊息
  render json: {
    error: {
      message: '資料處理失敗,請稍後再試',
      support_id: SecureRandom.uuid # 用於追蹤
    }
  }, status: :internal_server_error

效能考量

錯誤處理不應該成為效能瓶頸:

# 效能優化的錯誤處理
class OptimizedErrorHandler
  # 使用類別變數快取常用的錯誤回應
  @@error_templates = {}
  
  def self.render_error(type, details = {})
    # 快取錯誤模板避免重複產生
    @@error_templates[type] ||= load_error_template(type)
    
    # 合併動態資料
    @@error_templates[type].merge(details)
  end
  
  # 避免在錯誤處理中做昂貴的操作
  def self.log_error(exception, context)
    # 使用背景任務處理非關鍵的錯誤記錄
    ErrorLoggingJob.perform_later(
      error_class: exception.class.name,
      message: exception.message,
      context: context,
      backtrace: exception.backtrace&.first(10) # 限制堆疊大小
    )
  end
end

實踐練習

基礎練習:建立錯誤處理中介軟體(30 分鐘)

練習目標

這個練習將幫助你理解 Rails 的錯誤處理機制,並建立一個可重用的錯誤處理系統。你將學習如何組織錯誤類別、統一錯誤格式,以及追蹤請求。

詳細步驟說明

  1. 建立錯誤類別階層
    首先,我們需要定義清晰的錯誤分類,這樣才能針對不同類型的錯誤採取適當的處理策略。

  2. 實作統一的錯誤序列化器
    錯誤序列化器確保所有錯誤都以一致的格式返回,這對前端開發者來說非常重要。

  3. 設定 rescue_from 處理器
    使用 Rails 的 rescue_from 機制,我們可以優雅地捕捉和處理各種錯誤。

  4. 加入請求 ID 追蹤
    請求 ID 是追蹤錯誤的關鍵,它能幫助我們在日誌中找到完整的請求流程。

完整解答

# Step 1: 建立錯誤類別階層
# app/errors/application_error.rb
module ApplicationError
  # 基礎錯誤類別,所有自訂錯誤都繼承自此
  class BaseError < StandardError
    attr_reader :status, :code, :details
    
    def initialize(message = nil, status: :internal_server_error, code: nil, details: {})
      @status = status
      @code = code || self.class.name.demodulize.underscore
      @details = details
      super(message || default_message)
    end
    
    private
    
    def default_message
      I18n.t("errors.#{@code}", default: '發生錯誤')
    end
  end
  
  # 認證相關錯誤
  class AuthenticationError < BaseError
    def initialize(message = '需要登入')
      super(message, status: :unauthorized, code: 'authentication_required')
    end
  end
  
  # 授權相關錯誤
  class AuthorizationError < BaseError
    def initialize(message = '權限不足')
      super(message, status: :forbidden, code: 'permission_denied')
    end
  end
  
  # 資源不存在錯誤
  class ResourceNotFoundError < BaseError
    def initialize(resource_type, resource_id)
      super(
        "找不到指定的#{resource_type}",
        status: :not_found,
        code: 'resource_not_found',
        details: { resource_type: resource_type, resource_id: resource_id }
      )
    end
  end
  
  # 驗證錯誤
  class ValidationError < BaseError
    def initialize(errors)
      super(
        '資料驗證失敗',
        status: :unprocessable_entity,
        code: 'validation_failed',
        details: { errors: format_errors(errors) }
      )
    end
    
    private
    
    def format_errors(errors)
      if errors.respond_to?(:full_messages)
        errors.full_messages
      else
        Array(errors)
      end
    end
  end
end

# Step 2: 實作統一的錯誤序列化器
# app/serializers/error_serializer.rb
class ErrorSerializer
  def initialize(error, request_id = nil)
    @error = error
    @request_id = request_id || Current.request_id
  end
  
  def to_json
    {
      error: {
        type: error_type,
        message: error_message,
        code: error_code,
        status: status_code,
        details: error_details,
        timestamp: Time.current.iso8601,
        request_id: @request_id
      }
    }
  end
  
  private
  
  def error_type
    case @error
    when ApplicationError::BaseError
      @error.code
    when ActiveRecord::RecordNotFound
      'record_not_found'
    when ActionController::ParameterMissing
      'parameter_missing'
    else
      'internal_error'
    end
  end
  
  def error_message
    case @error
    when ApplicationError::BaseError
      @error.message
    when ActiveRecord::RecordNotFound
      '找不到指定的資源'
    when ActionController::ParameterMissing
      "缺少必要參數:#{@error.param}"
    else
      Rails.env.production? ? '系統錯誤' : @error.message
    end
  end
  
  def error_code
    @error.respond_to?(:code) ? @error.code : error_type
  end
  
  def status_code
    if @error.respond_to?(:status)
      Rack::Utils::SYMBOL_TO_STATUS_CODE[@error.status]
    else
      500
    end
  end
  
  def error_details
    if @error.respond_to?(:details)
      @error.details
    else
      {}
    end
  end
end

# Step 3: 設定 rescue_from 處理器
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
  # 設定請求 ID
  before_action :set_request_id
  
  # 錯誤處理器(從具體到通用的順序很重要)
  rescue_from ApplicationError::BaseError, with: :handle_application_error
  rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
  rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
  rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing
  rescue_from StandardError, with: :handle_standard_error
  
  private
  
  # Step 4: 加入請求 ID 追蹤
  def set_request_id
    # 使用 HTTP header 中的請求 ID,或生成新的
    Current.request_id = request.headers['X-Request-Id'] || SecureRandom.uuid
    
    # 將請求 ID 加入回應 header
    response.headers['X-Request-Id'] = Current.request_id
  end
  
  def handle_application_error(error)
    log_error(error)
    render_error(error, error.status)
  end
  
  def handle_not_found(error)
    log_error(error, level: :info)
    render_error(error, :not_found)
  end
  
  def handle_validation_error(error)
    validation_error = ApplicationError::ValidationError.new(error.record.errors)
    log_error(validation_error, level: :info)
    render_error(validation_error, :unprocessable_entity)
  end
  
  def handle_parameter_missing(error)
    log_error(error, level: :warn)
    render_error(error, :bad_request)
  end
  
  def handle_standard_error(error)
    log_error(error, level: :error)
    
    # 在生產環境發送到錯誤追蹤服務
    if Rails.env.production?
      Sentry.capture_exception(error) if defined?(Sentry)
    end
    
    render_error(error, :internal_server_error)
  end
  
  def render_error(error, status)
    serializer = ErrorSerializer.new(error)
    render json: serializer.to_json, status: status
  end
  
  def log_error(error, level: :error)
    logger.public_send(level) do
      "Error: #{error.class} - #{error.message}\n" \
      "Request ID: #{Current.request_id}\n" \
      "Backtrace:\n#{error.backtrace&.first(5)&.join("\n")}"
    end
  end
end

# 建立 Current 類別來儲存請求範圍的資料
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
  attribute :request_id
  attribute :user
  attribute :api_version
end

測試你的實作

建立測試檔案來驗證錯誤處理是否正常運作:

# spec/requests/error_handling_spec.rb
require 'rails_helper'

RSpec.describe 'Error Handling', type: :request do
  describe 'GET /api/v1/not_existing_endpoint' do
    it '返回 404 錯誤與正確格式' do
      get '/api/v1/not_existing_endpoint'
      
      expect(response).to have_http_status(:not_found)
      
      json = JSON.parse(response.body)
      expect(json['error']).to include(
        'type' => 'routing_error',
        'status' => 404,
        'request_id' => be_present,
        'timestamp' => be_present
      )
    end
  end
  
  describe 'POST /api/v1/courses without required params' do
    it '返回 400 錯誤與缺少的參數資訊' do
      post '/api/v1/courses', params: {}
      
      expect(response).to have_http_status(:bad_request)
      
      json = JSON.parse(response.body)
      expect(json['error']['type']).to eq('parameter_missing')
      expect(json['error']['message']).to include('缺少必要參數')
    end
  end
  
  describe 'Request ID tracking' do
    it '在回應中包含請求 ID' do
      get '/api/v1/courses'
      
      expect(response.headers['X-Request-Id']).to be_present
    end
    
    it '使用客戶端提供的請求 ID' do
      request_id = SecureRandom.uuid
      get '/api/v1/courses', headers: { 'X-Request-Id' => request_id }
      
      expect(response.headers['X-Request-Id']).to eq(request_id)
    end
  end
end

驗證方式

執行以下命令來測試你的錯誤處理系統:

# 測試 404 錯誤
curl -i http://localhost:3000/api/v1/courses/999999

# 測試 400 錯誤(缺少參數)
curl -i -X POST http://localhost:3000/api/v1/enrollments \
  -H "Content-Type: application/json" \
  -d '{}'

# 測試 401 錯誤(未認證)
curl -i http://localhost:3000/api/v1/admin/users

# 測試請求 ID 追蹤
curl -i -H "X-Request-Id: test-123" http://localhost:3000/api/v1/courses

進階挑戰:實作斷路器模式(1 小時)

挑戰目標

斷路器模式是微服務架構中的重要模式,它能防止故障的連鎖反應。在這個挑戰中,你將為 LMS 的影片轉碼服務實作一個完整的斷路器,學習如何保護系統免受外部服務故障的影響。

詳細實作指引

斷路器有三種狀態:

  1. Closed(關閉):正常狀態,請求正常通過
  2. Open(開啟):故障狀態,請求直接失敗
  3. Half-Open(半開):測試狀態,允許少量請求通過以測試服務是否恢復

完整解答

# app/services/circuit_breaker_v2.rb
class CircuitBreakerV2
  # 斷路器的狀態機實作
  class State
    attr_reader :circuit_breaker
    
    def initialize(circuit_breaker)
      @circuit_breaker = circuit_breaker
    end
    
    def call(&block)
      raise NotImplementedError
    end
    
    def on_success
      # 子類別實作
    end
    
    def on_failure
      # 子類別實作
    end
  end
  
  # 關閉狀態:正常運作
  class ClosedState < State
    def call(&block)
      begin
        result = block.call
        on_success
        result
      rescue => e
        on_failure
        raise
      end
    end
    
    def on_success
      circuit_breaker.reset_failure_count
    end
    
    def on_failure
      circuit_breaker.record_failure
      
      if circuit_breaker.threshold_reached?
        circuit_breaker.trip_breaker
      end
    end
  end
  
  # 開啟狀態:快速失敗
  class OpenState < State
    def call(&block)
      if circuit_breaker.timeout_expired?
        circuit_breaker.attempt_reset
        circuit_breaker.state.call(&block)
      else
        raise CircuitOpenError.new(
          "斷路器開啟中",
          service: circuit_breaker.name,
          retry_after: circuit_breaker.time_until_retry
        )
      end
    end
  end
  
  # 半開狀態:測試恢復
  class HalfOpenState < State
    def call(&block)
      begin
        result = block.call
        on_success
        result
      rescue => e
        on_failure
        raise
      end
    end
    
    def on_success
      circuit_breaker.reset
    end
    
    def on_failure
      circuit_breaker.trip_breaker
    end
  end
  
  # 自訂錯誤類別
  class CircuitOpenError < StandardError
    attr_reader :service, :retry_after
    
    def initialize(message, service:, retry_after:)
      @service = service
      @retry_after = retry_after
      super(message)
    end
  end
  
  attr_reader :name, :failure_threshold, :success_threshold, 
              :timeout, :state, :metrics
  
  def initialize(name, options = {})
    @name = name
    @failure_threshold = options[:failure_threshold] || 5
    @success_threshold = options[:success_threshold] || 2
    @timeout = options[:timeout] || 60
    @failure_count = 0
    @success_count = 0
    @last_failure_time = nil
    @state = ClosedState.new(self)
    
    # 監控指標
    @metrics = {
      total_calls: 0,
      successful_calls: 0,
      failed_calls: 0,
      rejected_calls: 0,
      state_changes: []
    }
    
    # 使用 Redis 儲存狀態(支援分散式系統)
    @redis = Redis.new
    @state_key = "circuit_breaker:#{name}:state"
    @metrics_key = "circuit_breaker:#{name}:metrics"
    
    load_state
  end
  
  def call(&block)
    @metrics[:total_calls] += 1
    
    begin
      @state.call(&block)
    rescue CircuitOpenError => e
      @metrics[:rejected_calls] += 1
      
      # 執行降級策略
      if block_given?
        fallback_result = yield_fallback
        return fallback_result if fallback_result
      end
      
      raise
    end
  end
  
  def reset_failure_count
    @failure_count = 0
    @success_count += 1
    @metrics[:successful_calls] += 1
    save_state
  end
  
  def record_failure
    @failure_count += 1
    @success_count = 0
    @last_failure_time = Time.current
    @metrics[:failed_calls] += 1
    save_state
  end
  
  def threshold_reached?
    @failure_count >= @failure_threshold
  end
  
  def timeout_expired?
    return false unless @last_failure_time
    Time.current - @last_failure_time >= @timeout
  end
  
  def time_until_retry
    return 0 unless @last_failure_time
    [@timeout - (Time.current - @last_failure_time), 0].max
  end
  
  def trip_breaker
    transition_to(OpenState.new(self))
    @last_failure_time = Time.current
    
    # 發送告警
    notify_state_change(:open)
  end
  
  def attempt_reset
    transition_to(HalfOpenState.new(self))
    notify_state_change(:half_open)
  end
  
  def reset
    @failure_count = 0
    @success_count = 0
    @last_failure_time = nil
    transition_to(ClosedState.new(self))
    notify_state_change(:closed)
  end
  
  # 監控介面
  def status
    {
      name: @name,
      state: state_name,
      failure_count: @failure_count,
      success_count: @success_count,
      last_failure: @last_failure_time&.iso8601,
      time_until_retry: time_until_retry,
      metrics: @metrics
    }
  end
  
  private
  
  def state_name
    @state.class.name.demodulize.underscore.gsub('_state', '')
  end
  
  def transition_to(new_state)
    old_state = state_name
    @state = new_state
    
    @metrics[:state_changes] << {
      from: old_state,
      to: state_name,
      timestamp: Time.current.iso8601
    }
    
    Rails.logger.info "斷路器 #{@name} 狀態轉換:#{old_state} -> #{state_name}"
    save_state
  end
  
  def save_state
    state_data = {
      state: state_name,
      failure_count: @failure_count,
      success_count: @success_count,
      last_failure_time: @last_failure_time&.to_i
    }
    
    @redis.set(@state_key, state_data.to_json, ex: 3600)
    @redis.set(@metrics_key, @metrics.to_json, ex: 3600)
  end
  
  def load_state
    state_json = @redis.get(@state_key)
    return unless state_json
    
    state_data = JSON.parse(state_json)
    @failure_count = state_data['failure_count']
    @success_count = state_data['success_count']
    @last_failure_time = Time.at(state_data['last_failure_time']) if state_data['last_failure_time']
    
    @state = case state_data['state']
             when 'open' then OpenState.new(self)
             when 'half_open' then HalfOpenState.new(self)
             else ClosedState.new(self)
             end
    
    metrics_json = @redis.get(@metrics_key)
    @metrics = JSON.parse(metrics_json).symbolize_keys if metrics_json
  end
  
  def notify_state_change(new_state)
    # 發送通知到監控系統
    CircuitBreakerNotificationJob.perform_later(
      name: @name,
      state: new_state,
      metrics: @metrics
    )
  end
  
  def yield_fallback
    # 子類別可以覆寫此方法提供降級策略
    nil
  end
end

# 影片處理服務的斷路器實作
# app/services/video_processing_circuit_breaker.rb
class VideoProcessingCircuitBreaker < CircuitBreakerV2
  def initialize
    super('video_processing', 
      failure_threshold: 3,
      success_threshold: 2,
      timeout: 30
    )
  end
  
  def process_video(video_file, options = {})
    call do
      # 實際的影片處理邏輯
      VideoProcessingAPI.new.process(video_file, options)
    end
  rescue CircuitOpenError => e
    # 降級策略:返回預設處理狀態
    handle_circuit_open(video_file, e)
  end
  
  private
  
  def handle_circuit_open(video_file, error)
    Rails.logger.warn "影片處理服務斷路器開啟,使用降級策略"
    
    # 將任務加入延遲佇列
    VideoProcessingRetryJob.set(wait: error.retry_after.seconds)
      .perform_later(video_file.id)
    
    # 返回降級回應
    {
      status: 'queued',
      message: '影片處理服務暫時繁忙,已加入處理佇列',
      estimated_time: error.retry_after + 60,
      job_id: SecureRandom.uuid
    }
  end
end

# 使用斷路器的控制器
# app/controllers/api/v1/video_uploads_controller.rb
class Api::V1::VideoUploadsController < ApplicationController
  def create
    video = current_user.videos.build(video_params)
    
    if video.save
      # 使用斷路器保護的影片處理
      processing_result = video_processing_service.process_video(
        video.file,
        quality: params[:quality] || 'auto'
      )
      
      video.update!(
        processing_status: processing_result[:status],
        processing_job_id: processing_result[:job_id]
      )
      
      render json: {
        video: VideoSerializer.new(video),
        processing: processing_result
      }, status: :created
    else
      render_validation_errors(video.errors)
    end
  end
  
  private
  
  def video_processing_service
    @video_processing_service ||= VideoProcessingCircuitBreaker.new
  end
  
  def video_params
    params.require(:video).permit(:title, :description, :file)
  end
end

# 監控儀表板
# app/controllers/admin/circuit_breakers_controller.rb
class Admin::CircuitBreakersController < Admin::BaseController
  def index
    @circuit_breakers = [
      VideoProcessingCircuitBreaker.new,
      PaymentGatewayCircuitBreaker.new,
      EmailServiceCircuitBreaker.new
    ].map(&:status)
    
    render json: {
      circuit_breakers: @circuit_breakers,
      summary: calculate_summary(@circuit_breakers)
    }
  end
  
  def reset
    breaker_name = params[:name]
    breaker = find_circuit_breaker(breaker_name)
    
    if breaker
      breaker.reset
      render json: { message: "斷路器 #{breaker_name} 已重置" }
    else
      render json: { error: '找不到指定的斷路器' }, status: :not_found
    end
  end
  
  private
  
  def find_circuit_breaker(name)
    case name
    when 'video_processing'
      VideoProcessingCircuitBreaker.new
    when 'payment_gateway'
      PaymentGatewayCircuitBreaker.new
    when 'email_service'
      EmailServiceCircuitBreaker.new
    end
  end
  
  def calculate_summary(breakers)
    {
      total: breakers.count,
      open: breakers.count { |b| b[:state] == 'open' },
      half_open: breakers.count { |b| b[:state] == 'half_open' },
      closed: breakers.count { |b| b[:state] == 'closed' },
      total_calls: breakers.sum { |b| b[:metrics][:total_calls] },
      total_failures: breakers.sum { |b| b[:metrics][:failed_calls] },
      failure_rate: calculate_failure_rate(breakers)
    }
  end
  
  def calculate_failure_rate(breakers)
    total_calls = breakers.sum { |b| b[:metrics][:total_calls] }
    total_failures = breakers.sum { |b| b[:metrics][:failed_calls] }
    
    return 0 if total_calls.zero?
    ((total_failures.to_f / total_calls) * 100).round(2)
  end
end

測試案例

# spec/services/circuit_breaker_v2_spec.rb
require 'rails_helper'

RSpec.describe CircuitBreakerV2 do
  let(:breaker) { described_class.new('test_service', failure_threshold: 2, timeout: 1) }
  
  describe '狀態轉換' do
    context '從 Closed 到 Open' do
      it '在達到失敗閾值後開啟' do
        # 第一次失敗
        expect { breaker.call { raise 'Error' } }.to raise_error('Error')
        expect(breaker.status[:state]).to eq('closed')
        
        # 第二次失敗,達到閾值
        expect { breaker.call { raise 'Error' } }.to raise_error('Error')
        expect(breaker.status[:state]).to eq('open')
      end
    end
    
    context '從 Open 到 Half-Open' do
      before do
        # 觸發斷路器開啟
        2.times { breaker.call { raise 'Error' } rescue nil }
      end
      
      it '在超時後轉為半開狀態' do
        expect(breaker.status[:state]).to eq('open')
        
        # 等待超時
        sleep(1.1)
        
        # 下次呼叫應該進入半開狀態
        expect { breaker.call { 'success' } }.not_to raise_error
        expect(breaker.status[:state]).to eq('closed')
      end
    end
    
    context '從 Half-Open 到 Closed' do
      before do
        # 設定為半開狀態
        2.times { breaker.call { raise 'Error' } rescue nil }
        sleep(1.1)
      end
      
      it '成功後恢復為關閉狀態' do
        result = breaker.call { 'success' }
        expect(result).to eq('success')
        expect(breaker.status[:state]).to eq('closed')
      end
    end
    
    context '從 Half-Open 回到 Open' do
      before do
        # 設定為半開狀態
        2.times { breaker.call { raise 'Error' } rescue nil }
        sleep(1.1)
      end
      
      it '失敗後回到開啟狀態' do
        expect { breaker.call { raise 'Error' } }.to raise_error('Error')
        expect(breaker.status[:state]).to eq('open')
      end
    end
  end
  
  describe '降級策略' do
    let(:video_breaker) { VideoProcessingCircuitBreaker.new }
    let(:video_file) { create(:video_file) }
    
    it '在斷路器開啟時返回降級回應' do
      # 觸發斷路器
      3.times do
        video_breaker.process_video(video_file) rescue nil
      end
      
      # 應該返回降級回應而非拋出錯誤
      result = video_breaker.process_video(video_file)
      
      expect(result[:status]).to eq('queued')
      expect(result[:message]).to include('暫時繁忙')
      expect(result[:job_id]).to be_present
    end
  end
  
  describe '監控指標' do
    it '正確記錄各種指標' do
      # 成功呼叫
      breaker.call { 'success' }
      
      # 失敗呼叫
      breaker.call { raise 'Error' } rescue nil
      
      metrics = breaker.status[:metrics]
      
      expect(metrics[:total_calls]).to eq(2)
      expect(metrics[:successful_calls]).to eq(1)
      expect(metrics[:failed_calls]).to eq(1)
      expect(metrics[:rejected_calls]).to eq(0)
    end
  end
end

評估標準

你的斷路器實作應該滿足以下標準:

  1. 正確的狀態轉換

    • Closed → Open:失敗次數達到閾值
    • Open → Half-Open:超時後自動轉換
    • Half-Open → Closed:測試成功
    • Half-Open → Open:測試失敗
  2. 合理的閾值設定

    • 失敗閾值不要太低(避免誤判)
    • 超時時間要適中(太短會頻繁重試,太長會延遲恢復)
  3. 優雅的降級處理

    • 提供有意義的降級回應
    • 記錄降級事件供後續分析
  4. 完整的測試覆蓋

    • 測試所有狀態轉換
    • 測試併發情況
    • 測試降級策略

通過這兩個練習,你應該已經掌握了 Rails 錯誤處理的核心概念和進階模式。記住,好的錯誤處理不只是技術問題,更是使用者體驗的重要組成部分。

知識連結

與前期內容的連結

  • Day 9 認證系統:認證失敗的錯誤需要特別處理,避免洩漏敏感資訊
  • Day 10 授權管理:權限不足的錯誤要提供清晰的指引
  • Day 11 API 版本控制:不同版本可能有不同的錯誤格式

對後續內容的鋪墊

  • Day 13 測試驅動開發:完善的錯誤處理讓測試更容易撰寫
  • Day 14 背景任務:錯誤重試機制在背景任務中更為重要
  • Day 19 效能優化:錯誤處理不應成為效能瓶頸

總結

今天我們深入探討了 Rails 的錯誤處理哲學,從基礎的 rescue_from 到進階的斷路器模式。我們學到了:

知識層面

  • Rails 的錯誤層次結構與處理機制
  • 統一錯誤格式的設計原則
  • 錯誤追蹤與監控的最佳實踐

思維層面

  • 錯誤不只是異常,更是溝通的機會
  • 好的錯誤處理能提升使用者體驗
  • 防禦性程式設計與信任框架的平衡

實踐層面

  • 能夠建立生產級的錯誤處理系統
  • 能夠整合錯誤監控服務
  • 能夠實作進階的容錯模式

自我檢核清單

完成今天的學習後,你應該能夠:

  • [ ] 解釋 Rails 錯誤處理與其他框架的差異
  • [ ] 實作統一且一致的錯誤回應格式
  • [ ] 識別並避免常見的錯誤處理陷阱
  • [ ] 在 LMS 專案中建立完整的錯誤處理機制
  • [ ] 整合錯誤追蹤服務並設定適當的告警

延伸資源

深入閱讀

相關 Gem

  • sentry-rails:完整的錯誤追蹤解決方案
  • rollbar:另一個優秀的錯誤監控服務
  • circuit_breaker:現成的斷路器實作

明日預告

明天我們將探討測試驅動開發(TDD)與 RSpec 實踐。如果說今天學習的是如何優雅地處理失敗,那明天就是如何預防失敗的發生。我們會深入 RSpec 的 DSL,學習如何寫出既是規格說明又是可執行測試的程式碼。準備好了嗎?讓測試成為你的設計工具,而不只是驗證工具。


上一篇
Day 11: API 版本控制與向後相容 - 優雅演進的藝術
系列文
30 天 Rails 新手村:從工作專案學會 Ruby on Rails13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言